Skip to content

feat(otel): otel sampling support in distributed tracing - #4177

Open
vpellan wants to merge 14 commits into
masterfrom
vpellan/otel-threshold-random-value
Open

feat(otel): otel sampling support in distributed tracing#4177
vpellan wants to merge 14 commits into
masterfrom
vpellan/otel-threshold-random-value

Conversation

@vpellan

@vpellan vpellan commented Sep 9, 2026

Copy link
Copy Markdown

Description

Add support for OTel sampling in distributed tracing, by reading and emitting ot.th and ot.rv tags. These tags will be forwarded to services using OTel SDKs, that should forward them to the backend.

Distributed tracing will work between DD-instrumented services and OTel-instrumented services, downstream OTel services will be able to forward the th field it to the collector and backend, which will calculate metrics correctly

Locally passes DataDog/system-tests#7518

Reviewer checklist

  • Test coverage seems ok.
  • Appropriate labels assigned.

@vpellan
vpellan requested review from a team as code owners September 9, 2026 14:51
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Sep 9, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 69 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-php | ASAN test_c: [8.0, arm64] — ❌ 1 test failed · 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

❌ tmp/build_extension/tests/ext/distributed_tracing/otel_sampling_caps.phpt (OpenTelemetry tracestate sampling honors value, member, and byte caps) from PHP.tmp.build_extension.tests.ext.distributed_tracing
002+ bytes=within-cap complete=no
     bytes=within-cap complete=yes
003+ owned-bytes=within-cap leading=dd,ot large=kept
002- bytes=within-cap complete=yes
003- owned-bytes=within-cap leading=dd,ot large=dropped
     ot-bytes=33 future=dropped next=kept
DataDog/apm-reliability/dd-trace-php | appsec integration tests: [test8.4-release-zts] — ❄️ 51 known flaky tests

View more details · View in GitLab

↳ ❄️ Known flaky: POST request sets content type and length() from com.datadog.appsec.php.integration.FrankenphpClassicTests

↳ ❄️ Known flaky: [1] file_get_contents, 19 from com.datadog.appsec.php.integration.FrankenphpClassicTests

↳ ❄️ Known flaky: [1] file_put_contents, /tmp/dummy, 9 from com.datadog.appsec.php.integration.FrankenphpClassicTests

↳ and 48 more — View all
DataDog/apm-reliability/dd-trace-php | ASAN test_c with multiple observers: [8.0] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

View all 69 failed jobs.

ℹ️ Info

No other issues found (see more)

❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 55.03% (+0.00%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 3f39364 | Docs | View more details | Give us feedback!

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T15:05:34.839614Z f7329a3 PR opened
🔒 Security Review Completed 2026-09-09T15:01:51.128033Z f7329a3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7329a3d7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tracer/otel_sampling.c Outdated
Comment on lines +182 to +186
} else if (decision == DDTRACE_OTEL_SAMPLING_DECISION_PROBABILITY &&
!fields.random_value && !fields.threshold && sample_rate > 0) {
ddtrace_otel_generate_fields(&fields, generated_random_value,
generated_threshold, trace_id,
sampling_priority, sample_rate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebuild ot fields after a local probability decision

When an inbound trace was dropped and a local target_span:any sampling rule makes a new probability decision, any valid inbound rv or th prevents regeneration here. The outbound traceparent can therefore say sampled while the preserved random value remains below the preserved threshold, or a partial member can omit the local threshold entirely, causing downstream OpenTelemetry services to calculate the wrong effective sampling probability. Preserve these fields only for INHERITED; a local probability decision needs fields reconciled with its own rate and final priority.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tracer/otel_sampling.c Outdated
Comment on lines +334 to +336
if (members.otel_member) {
ddtrace_otel_append_member(&limited, members.otel_member,
members.otel_member_len, &member_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve space for both owned tracestate members

With a large dd member—possible because propagated Datadog tags default to a 512-byte allowance—the first append can consume nearly the entire tracestate limit and the ot append then returns false. Its failure is ignored, and the later loop deliberately skips that ot member, so the emitted header silently loses the new sampling threshold exactly on traces carrying substantial propagated tags. The limiting pass should reserve capacity for both owned members or deliberately trim/evict lower-priority data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tracer/distributed_tracing_headers.c Outdated
Comment on lines +468 to +471
// W3C Trace Context permits at most 32 list-members. Owned members
// are removed or normalized after this pass, then protected when
// the outbound header is rebuilt.
if (last_comma && ++commas == 32) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply tracestate limits when constructing span links

Allowing all 32 extracted members is safe only on the outbound-header path, which runs the limiter again after rebuilding the dd member. DDTrace\SpanLink::fromHeaders() and the restart behavior instead pass this result to ddtrace_build_span_link_from_result(), where ddtrace_format_tracestate() can prepend a dd member for an extracted Datadog origin or tags without another limiting pass. An otherwise valid 32-member or 512-byte tracecontext input can therefore produce an over-limit traceState on the span link; retain the previous reservation or normalize the rebuilt link tracestate too.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't really need to be increased to 32, we just should handle "ot" as well and not increase commas for the "ot" case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53e88b1; dd and ot are excluded from the 30 vendor-member count so both owned slots remain available.

Comment on lines +392 to +395
span->otel_sampling_rate = sample_rate;
span->otel_sampling_decision = mechanism == DD_MECHANISM_MANUAL || mechanism == DD_MECHANISM_ASM || limited || sample_rate <= 0
? DDTRACE_OTEL_SAMPLING_DECISION_NON_PROBABILITY
: DDTRACE_OTEL_SAMPLING_DECISION_PROBABILITY;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat limiter-approved traces as non-probability decisions

When DD_TRACE_SAMPLE_RATE enables the trace rate limiter and the limiter approves the current candidate, limited is false, so this records a pure probability decision and emits a threshold based only on sample_rate. At throughput above DD_TRACE_RATE_LIMIT, however, admission also depends on the token-bucket limiter: for example, approved traces at sample rate 1 emit th:0 even though many equivalent traces are rejected by the limiter. Downstream OpenTelemetry metrics then treat the surviving spans as unsampled and undercount traffic; any decision for which the active limiter was consulted must omit the probability threshold (or encode the combined effective probability), not only decisions the limiter rejected.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RFC says the limiter changes attribution only when it converts a keep into a drop.

@github-actions

Copy link
Copy Markdown
Contributor

Snapshots difference summary

The following differences have been observed in committed snapshots. It is meant to help the reviewer.
The diff is simplistic, so please check some files anyway while we improve it.

If you need to update snapshots, please refer to CONTRIBUTING.md

2 occurrences of :

- "_dd.span_links": "[{\"trace_id\":\"0000000000000000c151df7d6ee5e2d6\",\"span_id\":\"c151df7d6ee5e2d6\",\"trace_state\":\"dd=t.dm:-0\",\"attributes\":{\"_dd.p.dm\":\"-0\"}}]"
+ "_dd.span_links": "[{\"trace_id\":\"0000000000000000c151df7d6ee5e2d6\",\"span_id\":\"c151df7d6ee5e2d6\",\"trace_state\":\"dd=t.dm:-0,ot=rv:ea5f0f7db45435;th:0\",\"attributes\":{\"_dd.p.dm\":\"-0\"}}]"

Comment thread tracer/distributed_tracing_headers.c Outdated
Comment on lines +478 to +479
zend_string *normalized_tracestate = ddtrace_otel_sampling_update_tracestate(
result.tracestate, trace_id.low, result.priority_sampling, DDTRACE_OTEL_SAMPLING_DECISION_INHERITED, 0);

@bwoebi bwoebi Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's odd to me to do a two-step normalization here. There's no reason why the otel parsing cannot be inlined in the loop above, producing a single normalization pass.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result.tracestate is supposed to contain the values untouched by us - i.e. other vendors, which get appended (as it should - new values should come first), ddtrace_otel_sampling_update_tracestate however manipulates the string in place every time it touches it.
Let's please not store individual data (like the otel rv and th) inside the string after parsing. Store it here on result, make it properly available on the span (for inspection / manual changing / interaction with otel sdk), and rebuild it alongside the dd= tags in ddtrace_format_tracestate.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also help keeping the implementation tidier, keeping scanning separate from formatting.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, in fact, you're storing otel_sampling_rate on the span, but never storing the parsed values there, which strikes me as odd.
Because, in fact, the otel_sampling_rate seems to always be equal to the datadog sampling rate (making it redundant as is??)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 89ab3d4:

  • it now parses and removes ot during the existing normalization pass.
  • parsed OTel fields are stored separately on the trace/span and rebuilt during formatting.
  • extraction now parses state, while injection handles formatting.
  • parsed values are stored on the span and the redundant otel_sampling_rate was removed.

Comment thread tracer/otel_sampling.c
Comment on lines +108 to +110
if (sample_rate >= 1) {
return 0;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant condition with the if just below?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was redundant and has been removed in 189d449

Comment thread tracer/otel_sampling.c Outdated
Comment on lines +157 to +158
ddtrace_otel_encode_56_bit_hex(random_value_int, random_value);
ddtrace_otel_encode_56_bit_hex(threshold_value, threshold);

@bwoebi bwoebi Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you'd print it directly to the smart_str, you could use smart_str_append_printf(&result, "rv:%014x", random_value) instead of reimplementing hex-printing. (and then remove trailing zeroes on the threshold)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in af34e57; sampling fields are now formatted directly with smart_str_append_printf

@vpellan
vpellan force-pushed the vpellan/otel-threshold-random-value branch from 35dfbcb to 3f39364 Compare September 11, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants